Skip to content

Latest commit

 

History

History
408 lines (314 loc) · 13.9 KB

File metadata and controls

408 lines (314 loc) · 13.9 KB

Unit Testing Best Practices in Swift

Table Of Contents

  1. Follow the FIRST Principles
  2. Test Only Public Methods
  3. Name the Test Properly
  4. Follow the AAA structure
  5. Avoid Logic in Tests
  6. Avoid Test Interdependence
  7. Perform One Act Per Test Method
  8. Perform One Assertion Per Test Method
  9. Use Specialized Assertions
  10. Use Simple Values in Assertions
  11. Asserting Floating Point Numbers
  12. Avoid Magic Strings
  13. Automate Mocks Generation
  14. Use setUp() and tearDown() To Control Side-effects
    1. Prepare and Tear Down State for a Test Class
    2. Prepare and Tear Down State for Each Test Method
    3. Tear Down State After a Specific Test Method
  15. Use the Power of Properties
  16. Use the Power of Helper Methods
  17. Use Proper Testing Strategies for UI Testing
  18. References

Follow the FIRST Principles

  • F - Fast:
    • Unit tests should take little time to run. Milliseconds.
  • I - Isolated (standalone):
    • Unit tests should be run in isolation, and have no dependencies (e.g. a file system or database).
  • R - Repeatable (deterministic):
    • Unit tests should always return the same result if you don't change anything in between runs.
  • S - Self-validating:
    • Unit tests should be able to automatically detect if they passed or failed. There must be no manual check or human interaction.
  • T - Timely:
    • Unit tests shouldn't take a disproportionately long time to write compared to the code being tested.
    • If you find testing the code taking a large amount of time compared to writing the code, consider a design that is more testable.

Test Only Public Methods

  • In most cases, there shouldn't be a need to test a private method.
    • Private methods are an implementation detail and never exist in isolation.
  • Instead, test public methods.
    • What you should care about is the end result of the public method that calls into the private one.
    • You can validate private methods by unit testing public methods.
      • At some point, there's going to be a public facing method that calls the private method as part of its implementation.

Name the Test Properly

Name your tests properly. Use a consistent naming convention.

Example of a good template for unit tests naming convention:

func test_methodName_whenCondition_shouldExpectation {}

// OR

func test_behaviour_whenCondition_shouldExpectation {}
    

Follow the Arrange-Act-Assert structure

Separate tested functionality from the setup and assertion by using Arrange-Act-Assert (or Given-When-Then):

  1. Arrange / Given:
  • Initialize test data.
  • Initialize SUT (system under test).
  1. Act / When:
  • Call tested function (usually on the SUT).
  • Capture a result.
  1. Assert / Then:
  • Check that the result matches what is expected.

Example:

func testInvalidEmail() {
    // 1. Arrange
    let invalidEmail = "ab.com"
    let sut = EmailValidator()

    // 2. Act
    let result = sut.isValid(invalidEmail)

    // 3. Assert
    XCTAssertFalse(result) 
}

Avoid Logic in Tests

To minimize the risk of introducing bugs in your unit tests, it's advisable to avoid implementing complex logic within them:

  • You can identify the presence of logic by assessing your use of constructs like if, while, for, switch.
  • Also, be cautious with operators such as do-catch which can introduce branching logic.

Avoid Test Interdependence

  • Each test should handle its own setup and tear down.
  • Test dependency occurs when one unit test depends on the outcome of another.

Perform One Act Per Test Method

When writing your tests, try to only include one act per test. Multiple acts need to be individually Asserted.

  • When the test fails, it is clear which act is failing.
  • Ensures that the test is focused on just a single case.
  • Gives you the entire picture as to why your tests are failing.
func test_addEmptyEntries_shouldBeTreatedAsZero() {
    // Arrange
    let sut = StringCalculator()

    // Act
    let actual1 = sut.add("")
    let actual2 = sut.add(",") // ❌

    // Assert
    XCTAssertEqual(0, actual1)
    XCTAssertEqual(0, actual2)
}

func test_addEmptyString_shouldBeTreatedAsZero() {
    // Arrange
    let sut = StringCalculator()

    // Act
    let actual = sut.add("") // ✅

    // Assert
    XCTAssertEqual(expected, actual)
}

Perform One Assertion Per Test Method

When writing your tests, try to only include one act per test.

Why?

  • When the test fails, it is clear which act is failing.
  • Ensures that the test is focused on just a single case.
  • Gives you the entire picture as to why your tests are failing.

Use Specialized Assertions

Use the most specialized assertion available when there is such a choice. Example:

// Equality
XCTAssert(x == y) // ❌
XCTAssertEqual(x, y) // ✅

// Nil and Non-nil
XCTAssert(x != nil) // ❌
XCTAssertNotNil(x) // ✅

Use Simple Values in Assertions

For instance, when testing square root method, do not use value for which nobody knows the answer.

func testSquareRoot() {
    XCTAssertEqual(sqrt(3), 1.73205080757, accuracy: epsilon) // ❌
    
    // Better, we all know that sqrt(4) = 2
    XCTAssertEqual(sqrt(4), 2, accuracy: epsilon) // ✅
}

Asserting Floating Point Numbers

Floating point numbers should not be compared for equality. Instead, we should verify that they are almost equal by using some error bound:

let x: Float = 1.48329351
let y: Float = 1.48329351

XCTAssertEqual(x, y) // ❌

let epsilon = 0.0001
XCTAssertEqual(x, y, accuracy: epsilon) // ✅

Avoid Magic Strings

Unit tests shouldn't contain magic strings.

func test_validate_whenPhoneNumberIsInvalid_shouldThrowException() {
    let sut = PhoneNumberValidator()
    
    let act = {
        try sut.validate("g122345j") // Magic string ❌
    }
    
    XCTAssertThrowsError(try act(), "Invalid number error should be thrown") { error in 
        XCTAssertEqual(error as? PhoneNumberValidator.Error, .invalidNumber)
    }
}
func test_validate_whenPhoneNumberIsInvalid_shouldThrowException() {
    let sut = PhoneNumberValidator()
    let INVALID_NUMBER = "g122345j"
    
    let act = {
        try sut.validate(INVALID_NUMBER) // ✅
    }
    
    XCTAssertThrowsError(try act(), "Invalid number error should be thrown") { error in 
        XCTAssertEqual(error as? PhoneNumberValidator.Error, .invalidNumber)
    }
}

Automate Mocks Generation

Sourcery is a great tool for mocking.

For a given protocol conforming to AutoMockable, it generates mocks that are ready-to-use in your tests. You can check:

  • Whether your functions were called.
  • The number of times your functions were called.
  • The parameters that were passed to the function calls.
  • Invoke a closure that takes the passed function parameters.
extension MyProtocol: AutoMockable {}

class MyProtocolMock: MyProtocol {

    //MARK: - sayHelloWith
    var sayHelloWithNameCallsCount = 0
    var sayHelloWithNameCalled: Bool {
        return sayHelloWithNameCallsCount > 0
    }
    var sayHelloWithNameReceivedName: String?
    var sayHelloWithNameReceivedInvocations: [String] = []
    var sayHelloWithNameClosure: ((String) -> Void)?

    func sayHelloWith(name: String) {
        sayHelloWithNameCallsCount += 1
        sayHelloWithNameReceivedName = name
        sayHelloWithNameReceivedInvocations.append(name)
        sayHelloWithNameClosure?(name)
    }
}

There are other useful protocols in Sourcery, for example AutoFixturable which makes creating new instances of an object easier

Use setUp() and tearDown() To Control Side-effects

If a property of a XCTestCase class is shared between unit tests, and our test changes the property, this indirectly affects the whole test suite. To prevent this from happening we must cleanup before and after each test. In our XCTestCase class, override following methods:

Prepare and Tear Down State for a Test Class

override class func setUp() {
    // This is the setUp() class method.
    // XCTest calls it before calling the first test method.
    // Set up any overall initial state here.
}
override class func tearDown() {
    // This is the tearDown() class method.
    // XCTest calls it after the last test method completes.
    // Perform any overall cleanup here.
}

Prepare and Tear Down State for Each Test Method

// MARK: setUp
override func setUp() async throws {
    // This is the setUp() async instance method.
    // XCTest calls it before each test method.
    // Perform any asynchronous setup in this method.
}

override func setUpWithError() throws {
    // This is the setUpWithError() instance method.
    // XCTest calls it before each test method.
    // Set up any synchronous per-test state that might throw errors here.
}

override func setUp() {
    // This is the setUp() instance method.
    // XCTest calls it before each test method.
    // Set up any synchronous per-test state here.
}

// MARK: tearDown
override func tearDown() {
    // This is the tearDown() instance method.
    // XCTest calls it after each test method.
    // Perform any synchronous per-test cleanup here.
}

override func tearDownWithError() throws {
    // This is the tearDownWithError() instance method.
    // XCTest calls it after each test method.
    // Perform any synchronous per-test cleanup that might throw errors here.
}

override func tearDown() async throws {
    // This is the tearDown() async instance method.
    // XCTest calls it after each test method.
    // Perform any asynchronous per-test cleanup here.
}

Tear Down State After a Specific Test Method

func testMethod1() throws {
    // This is the first test method.
    // Your testing code goes here.
    addTeardownBlock {
        // XCTest executes this when testMethod1() ends.
    }
}

func testMethod2() throws {
    // This is the second test method.
    // Your testing code goes here.
    addTeardownBlock {
        // XCTest executes this last when testMethod2() ends.
    }
    addTeardownBlock {
        // XCTest executes this first when testMethod2() ends.
    }
}

Use the Power of Helper Methods

You should be careful when initializing SUT (system under test) with all dependencies in a setUp() method. It cane easily result in Test Interdependence which should be avoided.

It's better to create factory method for initializing SUT with different configurations:

class UserStorageTests: XCTestCase {

    // MARK: - Helpers
    func makeSUT() -> UserStorage {
        UserStorage(storage: storageMock, secureStorage: secureStorageMock)
    }

    func makeSUT(with user: User) -> UserStorage {
        let sut = makeSUT()
        sut.save(user)
        return sut
    }
} 

Use the Power of Properties

Extract the duplicated test data and mocks into properties.

class UserStorageTests: XCTestCase {
    let userDefaults = UserDefaultsMock() // ✅
    let keychain = KeychainMock() // ✅
    let user = User(id: 1, username: "U1", password: "P1") // ✅

    func testUsernameSavedToStorage() {
        makeSUT().save(user)
        XCTAssertNotNil(userDefaults.inputUsername)
    }

    func testPasswordSavedToSecureStorage() {
        makeSUT().save(user)
        XCTAssertNotNil(keychain.inputPassword)
    }

    func makeSUT() -> UserStorage {
        UserStorage(storage: userDefaults, secureStorage: keychain)
    }
}

Use Proper Testing Strategies for UI Testing

The common strategies of testing UI:

  • End-to-end tests (think XCUI-driven-tests).
  • Snapshot tests:
    • Suited for testing visual layout.
    • Not suited for testing the content.
      • Testing the content of our UI is straightforward and can be done on low-level with unit tests.

Both kinds of tests are expensive to maintain since the user interface changes quite often. The UI doesn’t have to be tested in an end-to-end fashion.

Strategy for UI testing:

  • Use snapshot tests for testing visual layout.
  • Use low-level unit tests for testing the content.

References